Pool Liquidity Modify
Let's dive into how liquidity is modified within the pool. The source code can be found in src/libraries/Pool.sol
.
The role of the modifyLiquidity
function is to adjust liquidity within the pool. The first step in this function is to update tick information by modifying the liquidity at the boundaries of the specified tick range.
// src/libraries/Pool/modifyLiquidity
(state.flippedLower, state.liquidityGrossAfterLower) = updateTick(self, tickLower, liquidityDelta, false);
(state.flippedUpper, state.liquidityGrossAfterUpper) = updateTick(self, tickUpper, liquidityDelta, true);
Update Tick Information
Let's examine updateTick
function to understand how tick updates are performed. updateTick
is a core function in Uniswap V4, responsible for updating the state of a specific tick (price point) within the liquidity pool. It adjusts liquidity at the specified tick during liquidity provision or withdrawal and records the associated fee growth data.
The input parameter liquidityDelta
represents the amount of liquidity to adjust: positive values indicate adding liquidity, while negative values indicate removing it. The upper
represents whether the update targets the upper tick (true) or the lower tick (false).
// src/libraries/Pool
struct TickInfo {
uint128 liquidityGross;
int128 liquidityNet;
uint256 feeGrowthOutside0X128;
uint256 feeGrowthOutside1X128;
}
function updateTick(State storage self, int24 tick, int128 liquidityDelta, bool upper)
internal
returns (bool flipped, uint128 liquidityGrossAfter)
{
TickInfo storage info = self.ticks[tick];
uint128 liquidityGrossBefore = info.liquidityGross;
int128 liquidityNetBefore = info.liquidityNet;
liquidityGrossAfter = LiquidityMath.addDelta(liquidityGrossBefore, liquidityDelta);
flipped = (liquidityGrossAfter == 0) != (liquidityGrossBefore == 0);
if (liquidityGrossBefore == 0) {
if (tick <= self.slot0.tick()) {
info.feeGrowthOutside0X128 = self.feeGrowthGlobal0X128;
info.feeGrowthOutside1X128 = self.feeGrowthGlobal1X128;
}
}
int128 liquidityNet = upper ? liquidityNetBefore - liquidityDelta : liquidityNetBefore + liquidityDelta;
assembly ("memory-safe") {
sstore(
info.slot,
or(
and(liquidityGrossAfter, 0xffffffffffffffffffffffffffffffff),
shl(128, liquidityNet)
)
)
}
}
In the updateTick
function, the info
variable is an instance of the TickInfo
structure, which holds liquidity and fee growth data for a specific tick.
The parameter liquidityGross
from this structure represents the total liquidity at a given tick. This value aggregates liquidity contributions from multiple liquidity providers (LPs) at the same tick.
The role of liquidityNet
is to track the net liquidity change when the market price crosses this tick.
When the price moves up (left to right):
- Liquidity increases at lower ticks.
- Liquidity decreases at upper ticks.
When the price moves down (right to left):
- Liquidity decreases at lower ticks.
- Liquidity increases at upper ticks.
A positive value indicates liquidity is added when crossing the tick, while a negative value indicates liquidity is removed.
feeGrowthOutsideX128
(0 or 1) role is to accumulate the growth of fees in token (0 or 1) outside the tick, ensuring accurate fee distribution when liquidity crosses the tick.
Why do we need to record fee growth outside of the tick range? In the previous section, we analyzed the formula:
feesEarned = (feeGrowthInsideX128 - feeGrowthInsideLastX128) * liquidity / (1 << 128)
At first glance, it seems sufficient to record only the fee growth inside the tick range. However, to calculate feeGrowthInsideX128
, we rely on the following formula:
// src/libraries/Pool/getFeeGrowthInside
if (tickCurrent < tickLower) {
feeGrowthInside0X128 = lower.feeGrowthOutside0X128 - upper.feeGrowthOutside0X128;
feeGrowthInside1X128 = lower.feeGrowthOutside1X128 - upper.feeGrowthOutside1X128;
} else if (tickCurrent >= tickUpper) {
feeGrowthInside0X128 = upper.feeGrowthOutside0X128 - lower.feeGrowthOutside0X128;
feeGrowthInside1X128 = upper.feeGrowthOutside1X128 - lower.feeGrowthOutside1X128;
} else {
feeGrowthInside0X128 =
self.feeGrowthGlobal0X128 - lower.feeGrowthOutside0X128 - upper.feeGrowthOutside0X128;
feeGrowthInside1X128 =
self.feeGrowthGlobal1X128 - lower.feeGrowthOutside1X128 - upper.feeGrowthOutside1X128;
}
Thus, we need to obtain the feeGrowthOutsideX128
value beforehand.
// src/libraries/Pool/updateTick
if (tick <= self.slot0.tick()) {
info.feeGrowthOutside0X128 = self.feeGrowthGlobal0X128;
info.feeGrowthOutside1X128 = self.feeGrowthGlobal1X128;
}
We assume that all growth before a tick was initialized happened below the tick. When an initialized tick is smaller than the current tick, it indicates that the tick price has already been crossed. As a result, feeGrowthOutsideX128
is recorded as feeGrowthGlobalX128
.
We record feeGrowthOutsideX128 immediately for ticks smaller than the current tick during tick initialization because the current tick may lie within the range between tickLower
and tickUpper
. In this range, fees are generated as the price flows either upward or downward. To calculate these fees accumulated, we need to use the formula:
self.feeGrowthGlobalX128 - lower.feeGrowthOutsideX128
Since upper.feeGrowthOutsideX128
is zero in the context, it does not need to be considered.
If the upper tick is also smaller than the current tick, then during the initialization of the upper tick, its feeGrowthOutsideX128
will be set to the same value as feeGrowthGlobalX128
, just like the lower tick. When calculating the fees within the range, the formula:
upper.feeGrowthOutsideX128 - lower.feeGrowthOutsideX128 = 0
will be applied. This ensures that the fee calculation aligns with the design of the fee model, as no additional fees accumulate between the two ticks in this scenario.
Now, let's take a closer look at the flipped
snippet in the function.
We update liquidity information and use liquidityGrossAfter
to represent the new gross liquidity. When flipped
is true, it indicates that the liquidity status has changed. This change suggests that additional adjustments to the tick may be required in the future.
Later, we need to update liquidityNet
. It's important to note that liquidityDelta
is positive when the tick is crossed from left to right. When upper
is true, it indicates that the price has crossed the upper tick, meaning liquidity needs to be removed. Therefore, we calculate it as liquidityNetBefore - liquidityDelta
. Conversely, when crossing the lower tick, liquidity needs to be added, so we use liquidityNetBefore + liquidityDelta
.
Finally, we use assembly to update TickInfo
. This assembly block achieves the same effect as:
info.liquidityGross = liquidityGrossAfter;
info.liquidityNet = liquidityNet;
However, with normal Solidity writing, the data needs to be stored twice. By using assembly, we only perform a single storage operation (sstore), which saves gas.
Modify Liquidity
Since we now understand how to use updateTick
to update tick information, let's return to modifyLiquidity
to examine the later code.
The tickSpacingToMaxLiquidityPerTick
function calculates the maximum allowable liquidity per tick based on tick spacing. By comparing the function return maxLiquidityPerTick
with liquidityGrossAfter
, it ensures liquidity does not exceed the limit at any tick, preventing excessive concentration and promoting even distribution across the pool to reduce risks associated with liquidity imbalance.
uint128 maxLiquidityPerTick = tickSpacingToMaxLiquidityPerTick(params.tickSpacing);
if (state.liquidityGrossAfterLower > maxLiquidityPerTick) {
TickLiquidityOverflow.selector.revertWith(tickLower);
}
if (state.liquidityGrossAfterUpper > maxLiquidityPerTick) {
TickLiquidityOverflow.selector.revertWith(tickUpper);
}
Then we use flipTick
to toggle the state of an initialized tick. If the tick has liquidity, the corresponding tick bit is set to 1, otherwise is set to 0. The tick bit is stored in the following structure:
mapping(int16 wordPos => uint256) tickBitmap;
if (state.flippedLower) {
self.tickBitmap.flipTick(tickLower, params.tickSpacing);
}
if (state.flippedUpper) {
self.tickBitmap.flipTick(tickUpper, params.tickSpacing);
}
From the code we can see, the data structure is a seperate mapping. There are segments, each representing 256 ticks. Each tick's state occupies just one bit (1 or 0), significantly reducing storage overhead. Additinally, only initialized ticks occupy storage space, tick without liquidity do not require pre-allocated storage. This is a key advantage of using mapping instead of arrays, resulting in gas savings.
Next, we update position information and calculate the fees that can be earned.
(uint256 feeGrowthInside0X128, uint256 feeGrowthInside1X128) =
getFeeGrowthInside(self, tickLower, tickUpper);
Position.State storage position = self.positions.get(params.owner, tickLower, tickUpper, params.salt);
(uint256 feesOwed0, uint256 feesOwed1) =
position.update(liquidityDelta, feeGrowthInside0X128, feeGrowthInside1X128);
feeDelta = toBalanceDelta(feesOwed0.toInt128(), feesOwed1.toInt128());
First, getFeeGrowthInside
calculates the fee growth within the liquidity range. Then, position.update
updates the liquidity provider fees accumulated since the last liquidity adjustment. Finally, toBalanceDelta
compress the two uint256 values into one single uint256 called feeDelta
.
Next, the process checks and clear unnecessary tick data. if liquidityDelta < 0
, it indicates that liquidity is being withdrawn. If state.flip
has been triggered, it means all liquidity at this tick has been withdrawn, and the tick needs to be deleted.
if (liquidityDelta < 0) {
if (state.flippedLower) {
clearTick(self, tickLower);
}
if (state.flippedUpper) {
clearTick(self, tickUpper);
}
}
Next, we need to calculate the token amounts required to adjust liquidity. There are three scenarios to consider. The mathematical formula for calculating the required token amount can be found in the "Core Concepts" section.
1. Current tick is below the lower tick
When the tick moves from left to right, token0 becomes more valuable. In this case, we need to provide token0 exclusively.
if (tick < tickLower) {
delta = toBalanceDelta(
SqrtPriceMath.getAmount0Delta(
TickMath.getSqrtPriceAtTick(tickLower), TickMath.getSqrtPriceAtTick(tickUpper), liquidityDelta
).toInt128(),
0
);
2. Current tick is within the range of the lower and upper ticks
In this scenario, both token0 and token1 need to be provided. Since the current tick falls within the liquidity range, so we also need to update liquidity information.
if (tick < tickUpper) {
delta = toBalanceDelta(
SqrtPriceMath.getAmount0Delta(sqrtPriceX96, TickMath.getSqrtPriceAtTick(tickUpper), liquidityDelta)
.toInt128(),
SqrtPriceMath.getAmount1Delta(TickMath.getSqrtPriceAtTick(tickLower), sqrtPriceX96, liquidityDelta)
.toInt128()
);
self.liquidity = LiquidityMath.addDelta(self.liquidity, liquidityDelta);
}
3. Current tick is above the upper tick
The last case occurs when the current tick is higher than the upper tick. When the current tick moves from right to left, token1 becomes more valuable token, so we only need to provide token1.
delta = toBalanceDelta(
0,
SqrtPriceMath.getAmount1Delta(
TickMath.getSqrtPriceAtTick(tickLower), TickMath.getSqrtPriceAtTick(tickUpper), liquidityDelta
).toInt128()
);